#coding=utf-8
"""
LoNote 中介格式之底層操作模組

定義 LoNote 標準資料格式的底層 API。
透過 Tar 類別，所有資料都會以抽象路徑的形式存放，更上層不用管這些資料
實際上是怎樣被儲存的。

=================================
關於本模組的維護：
=================================

路徑命名法表記如下：

    fpath   = tar 檔案的完整路徑
    dir 或 dirname = 容納 tar 檔案的資料夾路徑
    updir   = tar 檔案的更新記錄資料夾
    modfile = tar 檔案的預備變更描述，為一 xml 檔案，位於 updir 下
    mapdir  = tar 檔案的擴展映射資料夾，位於 updir 之下，但不包含modfile
              收藏日後需更新到 tar 中的實體檔案。
    name    = 此 tar 中的（含路徑的）檔案名－－本名字可能為實體路徑或虛擬路徑。

    name_sysnorm = 表示實體檔案路徑，依不同作業系統而異。
    name_vnorm   = 表示虛擬檔案路徑，為在 tar 中記錄的路徑

---------------------------------

假設：
    fpath == '/home/xxx/lonote/bookname.lobook'

則：
    dir     == '/home/xxx/lonote'
    updir   == '/home/xxx/lonote/bookname'
    modfile == os.path.join('/home/xxx/lonote/bookname', modfile_name)
            == '/home/xxx/lonote/bookname/modify.xml'
    mapdir  == '/home/xxx/lonote/bookname/bookname'
    name    == 'bookname'

=================================

前綴表記法如下：

    loc_ 前綴 = "load or create" 的縮寫。
                表示嘗試讀取，如果不存在則新建它。
"""

import os
import os.path
import shutil
import xml.etree.ElementTree as ET
import tarfile
import functools

import lonote.civa.putil

# 自訂例外

class TarError(Exception): pass
class TarIOError(TarError): pass

class _path:
    '''用來計算路徑的函式'''
    modfile_name = 'modify.xml'
    def dirname(fpath):
        return os.path.dirname(fpath)
    def basename(fpath):
        return lonote.civa.putil.baseof(fpath)
    def modfile(fpath):
        return os.path.join(
                _path.dirname(fpath),
                _path.basename(fpath),
                _path.modfile_name)
    def updir(fpath):
        basename = _path.basename(fpath)
        return os.path.join(_path.dirname(fpath), basename)
    def mapdir(fpath):
        basename = _path.basename(fpath)
        updir = _path.updir(fpath)
        return os.path.join(updir, basename)
    def backupfile(fpath):
        return lonote.civa.putil.chext(fpath, 'bak')

# 輔助函式庫

def _load_book(fpath):
    '''讀取這本書

        成功後回傳開啟的 tarfile 檔案
        '''
    # 讀取或新建
    if os.path.isfile(fpath):
        if not tarfile.is_tarfile(fpath): # 檔案存在但不是 tar 格式
            info = '"{fpath}" are already exist!'.format(fpath = fpath)
            raise TarIOError(info)

        tar = tarfile.open(fpath,
                mode = 'r',
                format = tarfile.PAX_FORMAT,
                encoding = 'utf-8')
        return tar
    else:
        return None

def _create_book(fpath):
    '''建立一本書的 tar 檔案'''
    # 確保 dirname 存在
    dirname = _path.dirname(fpath)
    if not os.path.isdir(dirname):
        os.makedirs(dirname)
    # 確保所需名稱未被佔用
    if os.path.exists(fpath):
        raise TarIOError('fpath "{}" are already exist.'.format)
    updir = _path.updir(fpath)
    if os.path.exists(updir):
        raise TarIOError('updir "{}" are already exist.'.format)
    # 建立空白書籍
    with tarfile.open(fpath,
            mode = 'w',
            format = tarfile.PAX_FORMAT,
            encoding = 'utf-8') as tar:
        pass

def _loc_book(fpath):
    '''讀取一本書，如果讀不到則新建它。'''
    tar = _load_book(fpath)
    if tar is None:
        _create_book(fpath)
        tar = _load_book(fpath)
    return tar

def _init_updir(fpath):
    '''建立利用 tarfile path 來建立對應的 updir 與 modify

        如果 updir 已經存在，拋出 TarError
        '''
    def check_updir_not_exist(fpath):
        '''檢查 updir 是否不存在

            「存在」時拋出TarError
            '''
        updir = _path.updir(fpath)
        if os.path.exists(updir):
            info = 'updir "{updir}" are already exist!'.format(
                    updir = updir)
            raise TarError(info)

    # 建立 updir
    try:
        check_updir_not_exist(fpath)

        os.makedirs(_path.updir(fpath))
        os.makedirs(_path.mapdir(fpath))
    except TarError: pass
    # 與 modify.xml
    modfile = _path.modfile(fpath)
    with open(modfile, mode = 'w', encoding = 'utf-8') as f:
        f.write('<modify></modify>')

def _load_modify(modfile):
    '''如果 modify 存在，載入 modfile 否則'''
    if os.path.isfile(modfile):
        return ET.parse(modfile)
    else:
        return None

_norm_trans = str.maketrans('\\', '/')

def v_normpath(vpath):
    '''將虛擬路徑標準化

        寫入 tar 檔案中的 name 都必須標準化。這個步驟很重要。
        '''
    norm_vpath = os.path.normpath(vpath)
    return norm_vpath.translate(_norm_trans)

class Tar:
    '''可以指定抽象路徑，然後對路徑所指涉的檔案進行操作

        事實上，本物件內部對應到的所有路徑，實際上是存在於一個 tar 檔案中的
        '''

    def __init__(self, fpath):
        '''初始化

            如果現有檔案已經存在，讀取之。如果現有檔案不存在，新建之。
            '''
        # 變數設值
        self.__fpath = fpath
        self.name = _path.basename(fpath)
        self.__dirname = _path.dirname(fpath)
        self.__modfile = _path.modfile(fpath)
        self.__updir = _path.updir(fpath)
        self.__mapdir = _path.mapdir(fpath)
        self.__backupfile = _path.backupfile(fpath)

        # 讀取書籍
        self.__tar = _loc_book(fpath)

        # 讀取 modfile （如果存在）
        self.__et_modify = _load_modify(self.__modfile)

    @functools.lru_cache()
    def load(self, name, mode = 'bytes'):
        '''讀出檔案流，但可能是從 tar 中讀出，也可能是從 mapdir 中讀出。

            讀取優先順序是：
                1. 嘗試讀取 mapdir 的內容，讀到就立刻回傳。
                2. mapdir 如果讀不到，則讀取 modfile，確認檔案沒有被標明
                   已經被刪掉。（若檔案被刪拋出 KeyError）
                3. 如果檔案沒有被標明刪除，則讀取 tar。讀到立刻回傳，
                   否則拋出 KeyError

            name = tar 檔案中的理論路徑（同：以 mapdir 為工作目錄的路徑）
            mode = 為 'txt' 或 'bytes' 表示不同的讀檔方式。
                   若選 'txt' 一律使用 utf-8 編碼。
            '''
        name_vnorm = v_normpath(name)
        name_sysnorm = os.path.normpath(name)
        path = os.path.join(self.__mapdir, name_sysnorm)
        # 嘗試從 mapdir 讀出
        if os.path.isfile(path):
            if mode == 'txt':
                with open(path, mode = 'r', encoding = 'utf-8') as f:
                    data = f.read()
            elif mode == 'bytes':
                with open(path, mode = 'rb') as f:
                    data = f.read()
            else:
                mode_error = 'load mode "{}" are not defined!'.format(mode)
                raise TarError(mode_error)
            return data

        # 確認沒有被列入刪除
        elif self.__et_modify:
            match = "delete[@name='{}']".format(name_vnorm)
            e = self.__et_modify.find(match)
            if e is not None:
                key_error = 'File "{name}" not exist!'.format(name = name_vnorm)
                raise KeyError(key_error)

        # 嘗試從 tar 中讀出
        f = self.__tar.extractfile(name_vnorm)
        if not f:
            key_error = 'File "{name}" not exist!'.format(name = name_vnorm)
            raise KeyError(key_error)
        if mode == 'txt':
            data = f.read().decode('utf-8')
        elif mode == 'bytes':
            data = f.read()
        else:
            mode_error = 'load mode "{}" are not defined!'.format(mode)
            raise TarError(mode_error)
        return data

    def update(self, name, data, mode = 'bytes'):
        '''寫入（或複寫）資料流到 updir 中。

            name = tar 檔案中的理論路徑（同：以 mapdir 為工作目錄的路徑）
            mode = 為 'txt' 或 'bytes' 表示不同的寫檔方式。
                   若選 'txt' 一律使用 utf-8 編碼。
            data = 準備寫入的 data。
            '''
        # 準備資料夾
        name_sysnorm = os.path.normpath(name)
        path = os.path.join(self.__mapdir, name_sysnorm)
        dirname = os.path.dirname(path)
        if not os.path.isdir(dirname):
            os.makedirs(dirname)

        if mode == 'txt': # 寫入檔案
            with open(path, mode = 'w', encoding = 'utf-8') as f:
                f.write(data)
        else:
            with open(path, mode = 'wb') as f:
                f.write(data)

        # 如果 modfile 已經存在，確認更新後要移除 delete 信號。
        if self.__et_modify:
            name_vnorm = v_normpath(name)
            match = 'delete[@name="{}"]'.format(name_vnorm)
            e = self.__et_modify.find(match)
            if e is not None:
                self.__et_modify.getroot().remove(e)
                self.__et_modify.write(self.__modfile, encoding = "unicode")

        # 清掉快取
        self.load.cache_clear()

    def delete(self, name):
        '''刪除筆記中的資料流

            刪除操作法是：
                1. 如果 modfile 不存在，新建 modfile。
                2. 確認 modfile 中有且僅僅只有一個 name == name 的
                   delete tag。如果沒有就加上。
                3. 檢查 mapdir，如果有相應檔案，就將其刪除。

            name = tar 檔案中的理論路徑（同：以 mapdir 為工作目錄的路徑）
                   這就是要刪的檔案。
            '''
        name_vnorm = v_normpath(name)
        name_sysnorm = os.path.normpath(name)
        # 確認 modify 已經存在且初始化了。
        if not self.__et_modify:
            _init_updir(self.__fpath)
            self.__et_modify = _load_modify(self.__modfile)
        # 如果之前沒有刪除記錄，要補上（如果有那就不用加）
        e = self.__et_modify.find('delete[@name="{}"]'.format(name_vnorm))
        if e is None:
            e = ET.Element('delete', name = name_vnorm)
            self.__et_modify.getroot().append(e)
            self.__et_modify.write(self.__modfile, encoding = 'unicode')
        # 刪掉 mapdir 中的對應項目（如果有的話）
        path = os.path.join(self.__mapdir, name_sysnorm)
        try: os.remove(path)
        except OSError: pass
        # 清理空資料夾
        leafdir = os.path.dirname(path)
        try: os.removedirs(leafdir)
        except OSError: pass

        # 清掉快取
        self.load.cache_clear()

    def delete_dir(self, dirname):
        '''刪除 tar 中的資料夾

            這個動作的耗時與 tar 中項目數大致呈線性關係，請注意。
        '''
        dir_norm = v_normpath(dirname) + '/' # dirname 標準化
        for name in self.__tar.getnames():
            if name.startswith(dir_norm):
                self.delete(name)

    def flushed(self):
        '''檢查檔案是否處於已經被 flush 好的狀態'''
        if os.path.isdir(self.__updir):
            return False # updir 存在表示未 flush
        else:
            return True

    def flush(self, mode):
        '''將所有 updir 中的資料壓回 tarfile 中，更新 tarfile

            只有此操作才會真正更動 tar file，其他操作最多只會寫入 updir 中。

            本操作成功執行後，updir 會被刪除，所有資料會被凝縮回 tar 檔案中
            ，而 self.__et_modify 也會被重設為 None。

            mode = tarfile 的寫入壓縮模式，可為
                   'w' = 沒有壓縮，讀取速度最快空間消耗最大
                   'w:gz' = gz 壓縮
                   'w:bz2' = bz2 壓縮，空間消耗最小，速度最慢
                   輸入其他值可能會產生各種錯誤
            '''
        if self.flushed():
            return # 已經flush好則啥都不做……

        tempfile = self.__fpath + os.extsep + 'tmp'
        try: os.remove(tempfile) # 先刪掉暫存檔（如果存在）
        except: pass

        with tarfile.open(tempfile,
                mode = mode,
                format = tarfile.PAX_FORMAT,
                encoding = 'utf-8') as newtar:
            # 掃描舊的 tar 檔案，複製其中沒有變動過的舊檔
            for member in self.__tar.getmembers():
                match = 'delete[@name="{}"]'.format(member.name)
                if self.__et_modify and self.__et_modify.find(match) is not None:
                    pass # 被標記為刪除了，因此不複製
                else:
                    path = os.path.join(self.__mapdir, member.name)
                    # 如果檔案已經放在 mapdir 中，則不複製
                    # 之後再從 mapdir 更新
                    if not os.path.isfile(path):
                        f = self.__tar.extractfile(member)
                        newtar.addfile(member, f)
            # 掃描 mapdir，將所有新加入的檔案壓入。
            for dirpath, dirname, fnames in os.walk(self.__mapdir):
                for fname in fnames:
                    abspath = os.path.join(dirpath, fname)
                    relpath = os.path.relpath(abspath,
                            start = self.__mapdir)
                    relpath_norm = v_normpath(relpath)
                    newtar.add(abspath, arcname = relpath_norm)
        # 轉移成功，整理資料。
        self.__tar.close() # 關閉原本的 tar
        if os.path.isfile(self.__backupfile): # 刪掉舊有的 backup
            os.remove(self.__backupfile)
        os.rename(self.__fpath, self.__backupfile) # 當前的變成 backup
        os.rename(tempfile, self.__fpath) # 新建的變成當前的
        shutil.rmtree(self.__updir) # 把所有 updir 中的資料全部清掉
        self.__tar = _loc_book(self.__fpath)
        self.__et_modify = _load_modify(self.__modfile)

#     def expand(self):
#         '''flush 的反操作
# 
#             這樣日後讀取速度會比較快。
#             '''
#         if self.__tar.getmembers
#         # 如果 updir 存在，先 flush 一次
#         if os.path.isdir(self.__updir):
#             self.flush()

    def close(self):
        '''關閉 tar 讀取物件'''
        self.__tar.close()
